Skip to content

feat(adaptive-cards): add richCardTitleAsHeading styleOption to opt out of role=heading on rich card titles#5839

Open
cjennison wants to merge 7 commits into
microsoft:mainfrom
cjennison:a11y/rich-card-title-as-heading-option
Open

feat(adaptive-cards): add richCardTitleAsHeading styleOption to opt out of role=heading on rich card titles#5839
cjennison wants to merge 7 commits into
microsoft:mainfrom
cjennison:a11y/rich-card-title-as-heading-option

Conversation

@cjennison

Copy link
Copy Markdown

Summary

Make role="heading" / aria-level on hero/thumbnail/audio/video/animation/receipt card titles opt-out via a new styleOptions.richCardTitleAsHeading (default true, preserves today's behavior).

Why

Today AdaptiveCardBuilder.addCommonHeaders() hardcodes Adaptive Cards style: 'heading' on the card title TextBlock. The Adaptive Cards SDK then renders that as role="heading" + aria-level.

This was originally requested in issue #4327 (Title in Hero Card does not have aria-level specified) and shipped in 4.15.3.

However, downstream a11y audits — particularly for hosts where these cards appear inside a chat transcript (e.g. Microsoft Copilot Studio Test Chat) — flag the same heading as Unnecessary heading level is programmatically defined for "Title" under MAS 1.3.1 / WCAG 1.3.1, because card titles inside a chat are not page-level headings and pollute the document outline that assistive tech relies on.

The two requirements are mutually exclusive and both came from accessibility audits. The right fix is to make the host control it.

Behavior

styleOptions.richCardTitleAsHeading Title TextBlock renders as
true (default — unchanged from today) <div role="heading" aria-level="..." class="ac-textBlock">…</div> (per #4327)
false <div class="ac-textBlock">…</div> (no programmatic heading)

No breaking change — existing consumers keep today's output without action.

Test

Adds a sibling HTML test __tests__/html2/accessibility/attachment/heroCard.noHeading.html that mirrors the existing heroCard.heading.html, passes styleOptions = { richCardTitleAsHeading: false }, and asserts document.querySelector('.ac-textBlock[role="heading"]') is null.

Changelog

Added under [Unreleased]Added.

Notes

  • Followed AGENTS.md style: sorted property bag, no one-use intermediates, Required<AdaptiveCardsStyleOptions> updated in defaults.
  • Subtitle and text TextBlocks are intentionally left untouched (they never had style: 'heading').
  • Only addCommonHeaders is gated — that covers hero/thumbnail/audio/video/animation/receipt cards (every type that flows through addCommon).

…ut of role=heading on rich card titles

Today the title of hero/thumbnail/audio/video/animation/receipt cards is

rendered with Adaptive Cards style: 'heading', which the Adaptive Cards SDK

exposes as role='heading' + aria-level. This was originally requested in

issue microsoft#4327 and shipped in 4.15.3.

Subsequent a11y audits (e.g. for hosts where these cards appear inside a

chat transcript) flag the same heading as 'Unnecessary heading level is

programmatically defined for Title' under MAS 1.3.1 / WCAG 1.3.1, because

card titles inside a chat are not page-level headings and break document

outline tools.

Reconcile the two by making the behavior configurable via

styleOptions.richCardTitleAsHeading. Default is true so existing

consumers (including the original microsoft#4327 reporter) keep today's behavior;

consumers can pass false to drop the heading style.

Adds a sibling test heroCard.noHeading.html to the existing

heroCard.heading.html that asserts no .ac-textBlock[role='heading'] is

rendered when richCardTitleAsHeading is false.
Copilot AI review requested due to automatic review settings June 6, 2026 02:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR introduces a new Adaptive Cards style option to control whether rich card titles are rendered as programmatic headings for accessibility, defaulting to the historical behavior.

Changes:

  • Added styleOptions.richCardTitleAsHeading (default true) and documented it in the style options type.
  • Updated rich card header rendering to optionally omit Adaptive Cards style: 'heading'.
  • Added an accessibility HTML test covering the “no heading” configuration and updated the changelog.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/bundle/src/adaptiveCards/defaultStyleOptions.ts Adds a default value for the new richCardTitleAsHeading option.
packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts Conditionally applies style: 'heading' to rich card titles based on the new option.
packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts Documents and exposes the new style option in the public style options type.
tests/html2/accessibility/attachment/heroCard.noHeading.html Adds an accessibility regression test ensuring no heading role is applied when opted out.
CHANGELOG.md Announces the newly added style option.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread __tests__/html2/accessibility/attachment/heroCard.noHeading.html Outdated
Comment thread packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts Outdated
Comment thread packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts Outdated
@cjennison

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Christopher Jennison added 3 commits June 7, 2026 10:48
1. heroCard.noHeading.html: scope queries to the hero card activity

   container instead of querying the whole document. Match the title

   text block by its expected text so future text blocks elsewhere on

   the page do not make the test flaky.

2. AdaptiveCardsStyleOptions.ts: drop the incomplete 'reverse request'

   bullet that had no link; keep the @see link to microsoft#4327 only.

3. AdaptiveCardBuilder.ts: add https:// prefix to the microsoft#4327 URL so

   tooling auto-links it.
1. AdaptiveCardBuilder.ts: drop 'as const' on the conditional 'style: heading'.

   AGENTS.md says 'Avoid as'; the original code wrote 'style: heading'

   without any cast because addTextBlock takes Partial<TextBlock>, and

   TextBlock.style accepts string. Same here.

2. AdaptiveCardsStyleOptions.ts: tighten the doc-comment to match the

   actual call graph instead of listing card types.

   Cards that flow through addCommonHeaders today:

     - hero (via addCommon)

     - OAuth (direct)

     - thumbnail no-image branch (via addCommon)

     - animation/audio/video (via CommonCard -> addCommon)

   Cards that DON'T (their titles use direct addTextBlock w/o style:heading):

     - receipt

     - thumbnail with images

     - signin
expect(titleTextBlock).toBeTruthy();

expect(titleTextBlock.getAttribute('role')).toBe(null);
expect(heroCardActivity.querySelector('.ac-textBlock[role="heading"]')).toBe(null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: add both test one with visible styling for [role="heading"], and this one. Add snapshots for both so the change can be visually inspected.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea — added host.snapshot('local') to both heroCard.noHeading.html and heroCard.heading.html, and brought the heading test up to the same scoped-query pattern (locate the hero card activity, then match the title text block by its expected text and assert role='heading'). Both tests now also wait for allImagesLoaded so the captured snapshots are stable. Fixed in 74295fc.

@OEvgeny OEvgeny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, waiting for the tests update

Comment thread CHANGELOG.md Outdated

### Added

- Added `styleOptions.richCardTitleAsHeading` (default `true`) to opt out of `style: 'heading'` on rich card titles, by [@cjennison](https://github.com/cjennison). Resolves the conflict with [#4327](https://github.com/microsoft/BotFramework-WebChat/issues/4327) for hosts where card titles are not navigational headings.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow our format.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote the entry in the standard single-line <verb> <desc>, in PR [#NNNN](url), by [@author](url) form. Fixed in 74295fc.

…format

- heroCard.noHeading.html: add host.snapshot('local') and wait for allImagesLoaded so the captured snapshot is stable.
- heroCard.heading.html: bring the default-styling test to parity with the noHeading test - scope queries to the hero card activity, assert role='heading' on the title text block, and add host.snapshot('local') so both behaviors can be visually compared (per OEvgeny).
- CHANGELOG.md: rewrite the entry in the repo's standard '<verb> <desc>, in PR [#NNNN](url), by [@author](url)' single-line form (per compulim).
@compulim

compulim commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
image image

Please run tests locally, missing snapshots from new tests.

Generated by running the docker compose / selenium / jest stack from a clean WSL Ubuntu build:

  npm install && npm run build
  docker compose -f docker-compose-wsl2.yml up --detach --scale chrome=2
  ./node_modules/.bin/jest --ci=false --forceExit --runInBand --testPathPattern=heroCard -u

Both snapshots are visually identical (as expected - the only difference is the role='heading' attribute on the title text block, which has no visual styling). The behavioural difference is asserted via the role assertions in each test's JS.
@cjennison

Copy link
Copy Markdown
Author

{"body":"Generated and committed the baseline image snapshots for both tests in eed2536. Pipeline used: clean WSL Ubuntu npm install && npm run build, then docker compose -f docker-compose-wsl2.yml up --detach --scale chrome=2, then ./node_modules/.bin/jest --ci=false --forceExit --runInBand --testPathPattern=heroCard -u. 2 snapshots written, 22 existing snapshots passed, no diffs. The two new PNGs are visually identical (expected — role=\"heading\" doesn't change rendering by default), so the behavioural difference stays asserted via the role-attribute checks in the JS."}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants